TypeScript 97.6%
SQL 1.4%
JavaScript 0.5%
1"use client";23import * as React from "react";4import Link from "next/link";5import { useParams } from "next/navigation";6import { ArrowLeft, FlaskConical } from "lucide-react";7import type { GameDefinition, GameSymbol } from "@spinza/game-core";8import { Button } from "@/components/ui";9import { useAdminQuery } from "@/components/admin/use-query";10import type { GameDailyRow, GameDetailResponse, GameVersionRow, SimulationRunRow } from "@/components/admin/types";11import { PageHeader, RefreshButton, Panel, DataTable, ErrorState, TableSkeleton, TileSkeleton, KV, Pill, LifecyclePill, PassFail, StatGrid, StatTile, Mono, type Column } from "@/components/admin/primitives";12import { Bars, ChartFrame, CHART, Lines } from "@/components/admin/charts";13import { CertificationBlock } from "@/components/admin/simulation-results";14import { dateTime, int, isoDay, multiplier, num, pct, sc, shortDate, signedPct, titleCase } from "@/components/admin/format";15import { VOLATILITY_LABEL, cn } from "@/lib/utils";1617export default function AdminGameDetailPage() {18 const { slug } = useParams<{ slug: string }>();19 const q = useAdminQuery<GameDetailResponse>(slug ? `/api/admin/games/${slug}` : null);20 const d = q.data;21 const [certVersion, setCertVersion] = React.useState<string | null>(null);2223 const anchor = q.updatedAt;24 const daily = React.useMemo(() => (d ? fillDays(d.daily, anchor) : []), [d, anchor]);25 const currentVersion = d?.versions.find((v) => v.version === d.game.version) ?? d?.versions[0] ?? null;26 const shownCert = d?.versions.find((v) => v.version === (certVersion ?? currentVersion?.version))?.certification ?? null;2728 return (29 <>30 <PageHeader31 eyebrow="Game"32 title={d ? d.game.name : "Game detail"}33 description={34 d ? (35 <span className="inline-flex flex-wrap items-center gap-2">36 <Mono>37 {d.game.slug} · v{d.game.version}38 </Mono>39 <LifecyclePill lifecycle={d.game.lifecycle} />40 {currentVersion?.certification ? <PassFail pass={currentVersion.certification.status === "PASS"} label={`cert ${currentVersion.certification.status}`} /> : <Pill tone="warn">not certified</Pill>}41 </span>42 ) : undefined43 }44 actions={45 <>46 <Button variant="ghost" size="sm" href="/admin/games">47 <ArrowLeft className="h-3.5 w-3.5" /> All games48 </Button>49 <RefreshButton onClick={() => void q.refresh()} loading={q.refreshing} />50 <Button variant="accent" size="sm" href={`/admin/simulator?slug=${slug}`}>51 <FlaskConical className="h-3.5 w-3.5" /> Run simulation52 </Button>53 </>54 }55 />5657 {q.error && !d ? (58 <ErrorState error={q.error} onRetry={() => void q.refresh()} title={q.error.status === 404 ? "Game not found" : undefined} />59 ) : !d ? (60 <div className="space-y-4">61 <TileSkeleton count={6} cols={6} />62 <Panel>63 <TableSkeleton rows={8} cols={6} />64 </Panel>65 </div>66 ) : (67 <div className={cn("space-y-4", q.stale && "opacity-70")}>68 {/* Stats */}69 <StatGrid cols={6}>70 <StatTile label="Launches" value={int(d.stats?.launches ?? 0)} compact />71 <StatTile label="Spins" value={int(d.stats?.spins ?? 0)} compact />72 <StatTile label="Wagered / won" value={<span>{sc(d.stats?.wagered ?? 0)} <span className="text-fg-4">/</span> {sc(d.stats?.won ?? 0)}</span>} compact />73 <StatTile label="Effective RTP" value={pct(effRtp(d.stats?.wagered, d.stats?.won))} sub={d.definition ? `target ${pct(d.definition.rtp)}` : undefined} tone="accent" compact />74 <StatTile label="Hit rate" value={d.stats?.spins ? pct(num(d.stats.wins) / num(d.stats.spins)) : "—"} sub={`${int(d.stats?.bonuses ?? 0)} bonuses · ${int(d.stats?.freeSpins ?? 0)} FS`} compact />75 <StatTile label="Max win" value={multiplier(d.stats?.maxMultiplier ?? 0)} sub={`${sc(d.stats?.maxWin ?? 0)} · ${int(d.stats?.bigWins ?? 0)} big wins · ${int(d.stats?.favorites ?? 0)} ♥`} compact />76 </StatGrid>7778 <div className="grid gap-4 xl:grid-cols-[1fr_1.4fr]">79 {/* Definition */}80 <Panel title="Definition" description={d.definition ? d.definition.tagline : "Definition missing from the game library"}>81 {d.definition ? <DefinitionSummary def={d.definition} /> : <p className="text-[13px] text-danger">The slug is in the database but no definition is registered in @spinza/games.</p>}82 </Panel>8384 {/* Daily charts */}85 <Panel title="Last 30 days" description="Daily spins, distinct players and effective RTP">86 <div className="grid gap-5 md:grid-cols-3">87 <ChartFrame title="Spins" height={150}>88 <Bars data={daily} x="day" series={[{ key: "spins", label: "Spins", color: CHART.accent }]} xFormat={(v) => shortDate(String(v))} barSize={8} />89 </ChartFrame>90 <ChartFrame title="Players" height={150}>91 <Bars data={daily} x="day" series={[{ key: "players", label: "Players", color: CHART.grey }]} xFormat={(v) => shortDate(String(v))} barSize={8} />92 </ChartFrame>93 <ChartFrame title="RTP" height={150}>94 <Lines data={daily.filter((r) => r.rtp !== null)} x="day" series={[{ key: "rtp", label: "RTP", color: CHART.accent }]} xFormat={(v) => shortDate(String(v))} yFormat={(v) => `${Math.round(v * 100)}%`} reference={d.definition ? { y: d.definition.rtp } : undefined} dots />95 </ChartFrame>96 </div>97 {daily.every((r) => r.spins === 0) ? <p className="mt-3 text-center text-[12px] text-fg-4">No rounds recorded in the last 30 days.</p> : null}98 </Panel>99 </div>100101 {/* Symbols */}102 {d.definition ? (103 <Panel title="Symbols & pays" description={`Pays are multiples of ${d.definition.payModel.type === "ways" ? "a bet unit (total bet ÷ " + d.definition.betDivisor + ")" : "the line bet"}; scatter pays are × total bet`} padded={false}>104 <SymbolsTable def={d.definition} />105 </Panel>106 ) : null}107108 <div className="grid gap-4 xl:grid-cols-[1fr_1.4fr]">109 {/* Versions */}110 <Panel title="Versions" description="Click a version to view its certification" padded={false}>111 <DataTable<GameVersionRow>112 columns={[113 { key: "version", header: "Version", render: (v) => <span className={cn("font-mono text-[12px]", v.version === d.game.version ? "text-fg" : "text-fg-3")}>v{v.version}{v.version === d.game.version ? <span className="ml-1.5 text-[10px] uppercase text-accent-2">current</span> : null}</span> },114 { key: "status", header: "Status", render: (v) => <Pill tone={v.status === "published" ? "success" : v.status === "certified" ? "accent" : "muted"}>{v.status}</Pill> },115 { key: "cert", header: "Certification", render: (v) => (v.certification ? <PassFail pass={v.certification.status === "PASS"} /> : <span className="text-fg-4">none</span>) },116 { key: "created", header: "Created", render: (v) => <span className="text-fg-3">{dateTime(v.createdAt)}</span> },117 { key: "hash", header: "Hash", mono: true, render: (v) => <Mono title={v.definitionHash}>{v.definitionHash.slice(0, 12)}</Mono> },118 ]}119 rows={d.versions}120 rowKey={(v) => v.version}121 dense122 onRowClick={(v) => setCertVersion(v.version)}123 rowClassName={(v) => (v.version === (certVersion ?? currentVersion?.version) ? "bg-surface-2" : undefined)}124 empty="No versions recorded."125 />126 </Panel>127128 {/* Simulation runs */}129 <Panel title="Recent simulation runs" description="Latest 10 for this game" padded={false}>130 <DataTable<SimulationRunRow>131 columns={runCols}132 rows={d.simulationRuns}133 rowKey={(r) => r.id}134 dense135 empty="No simulations yet — run one from the simulator."136 />137 </Panel>138 </div>139140 {shownCert ? (141 <CertificationBlock cert={shownCert} title={`Certification · v${certVersion ?? currentVersion?.version}`} />142 ) : (143 <Panel title="Certification" tone="danger">144 <p className="text-[13px] text-fg-2">145 No certification report for v{certVersion ?? d.game.version}. Publishing is blocked until a simulation with <span className="font-semibold">certify</span> enabled passes for the current version.146 </p>147 <Button variant="outline" size="sm" className="mt-3" href={`/admin/simulator?slug=${slug}&certify=1`}>148 <FlaskConical className="h-3.5 w-3.5" /> Certify now149 </Button>150 </Panel>151 )}152 </div>153 )}154 </>155 );156}157158const runCols: Column<SimulationRunRow>[] = [159 { key: "created", header: "Started", render: (r) => <span className="text-fg-3">{dateTime(r.createdAt)}</span> },160 { key: "spins", header: "Spins", align: "right", render: (r) => int(r.spins) },161 { key: "status", header: "Status", render: (r) => <Pill tone={r.status === "done" ? "success" : r.status === "failed" ? "danger" : "info"}>{r.status === "running" ? `${Math.round((num(r.progress) / Math.max(1, num(r.spins))) * 100)}%` : r.status}</Pill> },162 { key: "rtp", header: "Observed RTP", align: "right", render: (r) => (r.result?.simulation ? <span>{pct(r.result.simulation.observedRtp, 3)} <span className="text-fg-4">({signedPct(r.result.simulation.deviation, 2)})</span></span> : "—") },163 { key: "cert", header: "Cert", render: (r) => (r.result?.certification ? <PassFail pass={r.result.certification.status === "PASS"} /> : <span className="text-fg-4">—</span>) },164 { key: "open", header: "", align: "right", render: (r) => <Link href={`/admin/simulator?run=${r.id}`} className="text-[12px] text-fg-3 hover:text-fg">Open →</Link> },165];166167function effRtp(wagered: unknown, won: unknown): number | null {168 const w = num(wagered);169 return w ? num(won) / w : null;170}171172function fillDays(rows: GameDailyRow[], anchor: number) {173 const byDay = new Map(rows.map((r) => [isoDay(r.day), r]));174 const out: { day: string; spins: number; players: number; rtp: number | null }[] = [];175 for (let i = 29; i >= 0; i--) {176 const d = new Date(anchor - i * 86400_000).toISOString().slice(0, 10);177 const r = byDay.get(d);178 out.push({ day: d, spins: r ? num(r.spins) : 0, players: r ? num(r.players) : 0, rtp: r && num(r.wagered) ? num(r.won) / num(r.wagered) : null });179 }180 return out;181}182183const FEATURE_KEYS: (keyof GameDefinition)[] = ["wild", "scatter", "cascades", "spinCollect", "holdRespin", "pickBonuses", "meter", "heat", "mystery", "randomFeatures", "quantum", "dynamicGrid", "jackpot"];184185function DefinitionSummary({ def }: { def: GameDefinition }) {186 const mechanics = FEATURE_KEYS.filter((k) => def[k] !== undefined);187 return (188 <div className="space-y-4">189 <KV190 cols={3}191 items={[192 { label: "Grid", value: `${def.grid.reels} × ${def.grid.rows}` },193 { label: "Pay model", value: def.payModel.type === "ways" ? "Ways" : `${def.payModel.lines.length} lines` },194 { label: "Volatility", value: `${VOLATILITY_LABEL[def.volatility] ?? def.volatility}` },195 { label: "Target RTP", value: pct(def.rtp) },196 { label: "Max multiplier", value: `${int(def.maxMultiplier)}×` },197 { label: "Bets", value: `${sc(def.minBet)} – ${sc(def.maxBet)}` },198 { label: "Pay scale", value: def.payScale.toFixed(4), mono: true },199 { label: "Bet divisor", value: String(def.betDivisor), mono: true },200 { label: "Theme", value: def.theme },201 ]}202 />203 <div>204 <div className="mb-1.5 text-[11px] font-medium uppercase tracking-wider text-fg-4">Features</div>205 <div className="flex flex-wrap gap-1.5">206 {def.featureNames.map((f) => (207 <Pill key={f} tone="accent">208 {f}209 </Pill>210 ))}211 {def.featureNames.length === 0 ? <span className="text-[12px] text-fg-4">—</span> : null}212 </div>213 </div>214 <div>215 <div className="mb-1.5 text-[11px] font-medium uppercase tracking-wider text-fg-4">Mechanics configured</div>216 <div className="flex flex-wrap gap-1.5">217 {mechanics.map((m) => (218 <Pill key={m} tone="neutral">219 {titleCase(String(m))}220 </Pill>221 ))}222 {def.freeSpinsPersistentMultiplier ? <Pill tone="neutral">Persistent FS multiplier</Pill> : null}223 </div>224 </div>225 <div>226 <div className="mb-1.5 text-[11px] font-medium uppercase tracking-wider text-fg-4">Presentation</div>227 <div className="flex flex-wrap items-center gap-2 text-[12px] text-fg-2">228 {(["primary", "secondary", "glow", "bg", "surface"] as const).map((k) => (229 <span key={k} className="inline-flex items-center gap-1">230 <span className="inline-block h-3 w-3 rounded-[3px] border border-line" style={{ background: def.presentation.palette[k] }} />231 {k}232 </span>233 ))}234 <span className="text-fg-4">·</span>235 <span>{def.presentation.particles}</span>236 <span className="text-fg-4">·</span>237 <span>{def.presentation.frame}</span>238 <span className="text-fg-4">·</span>239 <span>{def.presentation.backdrop}</span>240 </div>241 </div>242 {def.tags.length ? <div className="text-[12px] text-fg-4">{def.tags.map((t) => `#${t}`).join(" ")}</div> : null}243 </div>244 );245}246247function SymbolsTable({ def }: { def: GameDefinition }) {248 const counts = Array.from({ length: Math.max(0, def.grid.reels - 2) }, (_, i) => i + 3);249 const fmtWeight = (w: number | number[] | undefined) => (w === undefined ? "—" : Array.isArray(w) ? w.join(" / ") : String(w));250 const cols: Column<GameSymbol>[] = [251 {252 key: "id",253 header: "Symbol",254 render: (s) => (255 <span className="inline-flex items-center gap-2">256 <span className="inline-block h-3.5 w-3.5 rounded-[4px] border border-line" style={{ background: s.style.color, boxShadow: s.style.glow ? `0 0 ${Math.round(s.style.glow * 8)}px ${s.style.accent ?? s.style.color}` : undefined }} />257 <span className="font-medium text-fg">{s.name}</span>258 <Mono className="text-fg-4">{s.id}</Mono>259 </span>260 ),261 },262 { key: "kind", header: "Kind", render: (s) => <Pill tone={s.kind === "regular" ? "muted" : s.kind === "wild" ? "accent" : s.kind === "scatter" ? "info" : "warn"}>{s.kind}</Pill> },263 { key: "tier", header: "Tier", render: (s) => <span className="text-fg-3">{s.tier}</span> },264 { key: "weight", header: "Weight", align: "right", mono: true, render: (s) => fmtWeight(s.weight) },265 { key: "fsweight", header: "FS weight", align: "right", mono: true, render: (s) => (s.freeSpinWeight !== undefined ? fmtWeight(s.freeSpinWeight) : <span className="text-fg-4">—</span>) },266 ...counts.map<Column<GameSymbol>>((c) => ({267 key: `pay${c}`,268 header: `${c}×`,269 align: "right",270 render: (s) => {271 const p = s.pays?.[c];272 const sp = s.scatterPays?.[c];273 if (p === undefined && sp === undefined) return <span className="text-fg-4">·</span>;274 return (275 <span className="tabular">276 {p !== undefined ? p : null}277 {sp !== undefined ? <span className="text-info">{p !== undefined ? " / " : ""}{sp}×bet</span> : null}278 </span>279 );280 },281 })),282 ];283 return <DataTable columns={cols} rows={def.symbols} rowKey={(s) => s.id} dense />;284}285